今天我們要聊聊工作中遇到的一個問題。我們需要撰寫測試來驗證前端需求的正確性(也就是UI/UX)。於是我們想,E2E測試非常適合,但是因為環境和經費種種因素,該測試必須要在地端和隔離外部的狀況下執行。所以最終想到的解法就是使用E2E測試的做法,搭配整合測試的環境來進行。
首先,端對端測試(End-to-End Testing,E2E測試)旨在驗證應用程式的完整流程,確保從使用者的角度來看,所有組件和系統之間的互動都能正常運作。E2E測試應涵蓋整個應用程式的工作流程,包括從前端到後端,乃至與外部系統或第三方服務的整合。
而整合測試(Integration Testing)旨在驗證不同模組或組件之間的相互運作是否正確。當這些模組整合在一起時,透過進行整合測試來檢查它們在協同運作時是否如預期般正常。
而需要使用使用者角度進行驗證,但是需要隔離外部系統的測試,我們就專而將目標定義在兩者之間,也就是以使用者的角度驗證組件之間的交互是否正確。也因此稱為整合測試特性的E2E測試。
針對這樣的測試情境,我們決定使用Angular的 Core / Shared / Feature 專案架構,搭配Cypress,在模擬後端和第三方資料的情況下。在地端環境進行以頁面為單位使用者操作為基準的測試。
那麼讓我們開始吧!
我們將Angular專案中的檔案依照業務性質不同來分類到三個模組中。目的是讓程式碼更具組織性、可維護性和可重用性。
1. Core
- 核心職責:Core 模組通常包含應用程式中 單一實例(singleton) 的服務和全域(global)的功能,如 身份驗證服務、全域導航守衛 或 應用程式初始化邏輯。
- 範例內容:
- 認證、授權服務(Authentication/Authorization)
- HTTP 攔截器
- 全局配置(例如環境設定檔)
- 根導航守衛(Root Guards)
2. Shared
- 核心職責:Shared 模組用來存放 可重用的組件、指令、管道和服務。這些功能不屬於任何一個特定的業務功能,但可以在整個應用中重複使用。
- 範例內容:
- 共用的 UI 組件(如按鈕、表單元件)
- 常見的管道(Pipes,如貨幣格式化)
- 通用的指令(Directives)
- 可在多處使用的服務(例如彈窗服務)
3. Feature
- 核心職責:Feature 模組是應用程式的業務邏輯或功能區塊。每個 Feature 模組通常代表應用中的一個具體業務功能,如「使用者管理」、「產品管理」等。
- 範例內容:
- 使用者管理模組(User Management Module)
- 產品管理模組(Product Management Module)
- 訂單系統模組(Order System Module)
- 注意事項:每個 Feature 模組通常會有自己的組件、服務和路由,並且它們只會在需要的時候被載入,以實現按需加載(Lazy Loading)。
簡而言之,Core負責擺放系統運作所需要的全域單一實例服務,Shared負責擺放與業務無關,需要複用的原件或是服務,Feature負責擺放業務邏輯的實作。
ecommerce-app/
├── src/
│ ├── app/
│ │ ├── core/
│ │ │ ├── services/
│ │ │ │ ├── auth.service.ts
│ │ │ │ ├── error-handler.service.ts
│ │ │ │ └── api-interceptor.service.ts
│ │ │ └── app.component.ts <-- 應用程式的入口組件,註冊全局服務
│ │ │
│ │ ├── shared/
│ │ │ ├── components/
│ │ │ │ ├── button.component.ts <-- 共用按鈕組件
│ │ │ ├── pipes/
│ │ │ │ └── currency-format.pipe.ts <-- 貨幣格式化管道
│ │ │ └── directives/
│ │ │ └── example.directive.ts <-- 共用指令 (如果需要)
│ │ │
│ │ ├── features/
│ │ │ ├── products/
│ │ │ │ ├── product-list/
│ │ │ │ │ ├── product-list.component.ts <-- 商品列表組件
│ │ │ │ │ └── product-list.component.html
│ │ │ │ ├── product-details/
│ │ │ │ │ ├── product-details.component.ts <-- 商品詳情組件
│ │ │ │ │ └── product-details.component.html
│ │ │ │ └── product.service.ts <-- 商品相關的服務
│ │ │ ├── user/
│ │ │ │ ├── user-profile/
│ │ │ │ │ ├── user-profile.component.ts <-- 使用者資料組件
│ │ │ │ │ └── user-profile.component.html
│ │ │ │ └── user.service.ts <-- 使用者相關服務
│ │ │ └── cart/
│ │ │ ├── cart.component.ts <-- 購物車組件
│ │ │ └── cart.service.ts <-- 購物車服務
│ │ │
│ │ ├── app-routing.module.ts <-- 應用程式路由配置,包含 lazy loading
│ │ └── main.ts <-- 應用程式的啟動點
│ │
│ ├── assets/ <-- 靜態資源 (圖片、字體等)
│ └── environments/ <-- 環境設定檔 (開發、測試、產品環境)
│ ├── environment.ts
│ └── environment.prod.ts
這部分讓我們邀請ChatGpt為我們進行說明,
Me: 什麼是Cypress?
ChatGpt: Cypress 是一個現代化的前端測試工具,專門用來自動化測試網頁應用程式。它主要用於進行端對端測試(End-to-End testing)、單元測試(Unit testing)和整合測試(Integration testing)。Cypress 的設計目的是解決傳統網頁測試工具的一些缺點,如複雜的配置、速度慢以及不穩定的測試結果。
接著我們在專案中導入Cypress。
其實Cypress的安裝超級無敵簡單,官方網站的說明非常詳細易懂,下方我們快速上步驟。
- 安装 Cypress
npm install cypress --save-dev
- 初始化 Cypress
我們可以透過
npx cypress open
的指令快速建立資料夾結構。
cypress/ └── e2e/ └── fixtures/ └── plugins/ └── support/
- 設定
cypress.config.json
import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:4200', }, component: { devServer: { framework: 'angular', bundler: 'webpack', }, specPattern: '**/*.cy.ts', } });
- 更新
package.json
中的script
"scripts": { "cypress:open": "cypress open", "cypress:run": "cypress run" }
這樣我們可以直接透過
npm
來啟動Cypress。npm run cypress:open
- 安裝 mochawesome 和 JUnit Reporter 作為Cypress的報告產生器
npm install cypress-multi-reporters mocha-junit-reporter mochawesome --save-dev
- 更新
cypress.config.json
使用reporter
import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:4200', }, component: { devServer: { framework: 'angular', bundler: 'webpack', }, specPattern: '**/*.cy.ts', }, reporter: 'mocha-junit-reporter', reporterOptions: { mochaFile: 'cypress/results/junit-[hash].xml', toConsole: true, }, });
到此我們的環境就裝好了,下一步就是撰寫測試了。
首先我們的Cypress結構會分成四個資料夾,如下:
cypress/
├── e2e/
├── fixtures/
├── pom/
├── support/
└── tsconfig.json
- e2e資料夾負責存放所有的測試案例,並且測試案例的擺放會比照專案中的feature資料夾,以此來達成每一個頁面一個測試檔案。舉例來說,我們的專案的features如下,有三個feature,四個頁面。
│ │ ├── features/ │ │ │ ├── products/ │ │ │ │ ├── product-list/ │ │ │ │ │ ├── product-list.component.ts <-- 商品列表組件 │ │ │ │ │ └── product-list.component.html │ │ │ │ ├── product-details/ │ │ │ │ │ ├── product-details.component.ts <-- 商品詳情組件 │ │ │ │ │ └── product-details.component.html │ │ │ │ └── product.service.ts <-- 商品相關的服務 │ │ │ ├── user/ │ │ │ │ ├── user-profile/ │ │ │ │ │ ├── user-profile.component.ts <-- 使用者資料組件 │ │ │ │ │ └── user-profile.component.html │ │ │ │ └── user.service.ts <-- 使用者相關服務 │ │ │ └── cart/ │ │ │ ├── cart.component.ts <-- 購物車組件 │ │ │ └── cart.service.ts <-- 購物車服務
測試專案就會長的如下。
cypress/ ├── e2e/ │ └── feature/ │ ├── products/ │ │ ├── product-list.cy.ts │ │ └── product-details.cy.ts │ ├── user/ │ │ └── user-profile.cy.ts │ └── cart/ │ └── cart.cy.ts ├── fixtures/ ├── pom/ ├── support/ └── tsconfig.json
- fixtures資料夾負責擺放所有的假資料,假資料為json格式,且帶有
.data
的後墜。例如:Token.data.json
。並且若資料有正常異常等情境上的差異會再加入情境的後墜。例如:Token.normal.data.json
。假資料是所有測試共用的,以我們的解決方案來說,會用到假資料的時機就是要隔離外部服務的時候,所以第三方服務和後端API等都會使用fixture中的資料進行模擬,也因此,我們會使用API做為拆分fixture的力度。- pom資料夾負責擺放
Page Object Model
,這個設計模式會將每一個頁面封裝成物件,物件中的內容會是針對該頁面進行操作的共用cypress語法。透過這個方式進行管理,也減少每一次需求調整後要改動的測試量。- support資料夾通常會包含一些輔助性的設置或自定義的命令,跟pom主要的區別在,support中的共用命令是沒有特定業務邏輯的,也可以理解成是全域共用的。
接著是測案撰寫規範。一個測案中一樣分成兩層的 describe
。
第一層 describe
用於描述該測試頁面,第二層的 describe
則依照使用者跟該畫面的互動進行拆分,而其中的每一個 it
則代表了該操作的各種情境。
而測試檔案也與單元測試非常類似,分成:
- 設定與初始化區塊
- 測試區塊
- 清理區塊
為什麼沒有mock區塊呢? 因為所有的內容已經被封裝到POM裡面了,所以在測試檔案中,我們只需實作POM並且調用其中的方法即可。這個方式也能夠確保測試案例中只有測試的邏輯和操作。
我們以 product-list.component.ts
做為範例來實作一次。
- 首先建立該component的 POM
// pom/feature/product-list/product-list.pom.ts export class PomProductList { visit() { cy.visit('/product-list'); // 假設路徑是 /product-list } getProductItems() { return cy.get('[data-cy="product-item"]'); // 使用 data-cy 埋點選取產品項目 } getAddToCartButton(productName: string) { return cy.contains('[data-cy="product-item"]', productName).find('[data-cy="add-to-cart"]'); } clickAddToCartButton(productName: string) { this.getAddToCartButton(productName).click(); } getCartItemCount() { return cy.get('[data-cy="cart-item-count"]'); // 使用 data-cy 埋點選取購物車項目數量 } }
將所有與此頁面元件有關,或是需要mock的項目實作在POM中。
- 實作product-list.cy.ts
// e2e/feature/product-list/product-list.cy.ts import { ProductListPage } from '../../../pom/feature/product-list/ProductListPage'; describe('ProductList', () => { // 實作建立好的POM const productListPage = new ProductListPage(); beforeEach(() => { productListPage.visit(); // 在每次測試前都進入產品列表頁面 }); // 描述使用者操作 "添加產品到購物車" describe('Add Product to Cart', () => { it('should allow the user to add a product to the cart', () => { // Arrange // 模擬添加一個產品到購物車 const productName = 'Sample Product'; // 假設產品名稱 // Act productListPage.clickAddToCartButton(productName); // Assert // 檢查購物車中是否顯示產品 productListPage.getCartItemCount().should('contain', '1'); }); }); // 描述使用者操作 "檢查產品顯示" describe('Display Products', () => { it('should display multiple products', () => { // Arrange // Act // Assert // 檢查是否顯示多個產品項目 productListPage.getProductItems().should('have.length.greaterThan', 0); }); }); });
第一層
describe
定義了本測試檔案的範圍是ProductList
頁面,接著實作了我們要驗證的頁面POM,以調用與此component
互動的方法。接著在每一個測案執行前,都先前往該頁面。接著第二層describe
依照使用者會與此頁面互動的行為進行拆分。通常可將行為分成兩種,第一種是進入頁面後就會觸發的初始化行為,而其他則是由使用者操作觸發的操作行為。最後每一個it
測試案例使用AAA Pattern
進行撰寫。其實到這邊會發現,進行如此拆分後的測試變的非常容易撰寫,需要資源、模擬回傳、取得DOM等等的操作,全部到POM找,所有測試都可以專注在業務情境和測試邏輯。當然實務層面不會像範例這麼簡單,不過底層的設計想法和分工都在上面了,基本上都可以找到對應的分類。
到這邊就是我們解決方案的內容了,希望這樣 Angular Core / Shared / Feature,搭配 Cypress + POM設計模式 + AAA Pattern 的方式能夠幫助到有類似痛點的朋友。
而最後一篇文章我們就分享一下,如何將測試導入Azure的ci pipeline中,讓整個流程全部串接起來!